/*
	JSON-to-Go
	by Matt Holt

	https://github.com/mholt/json-to-go

	A simple utility to translate JSON into a Go type definition.
*/

function jsonToGo(json, typename, flatten = true, example = false, allOmitempty = false)
{
	let data;
	let scope;
	let go = "";
	let tabs = 0;

	const seen = {};
	const stack = [];
	let accumulator = "";
	let innerTabs = 0;
	let parent = "";
	let globallySeenTypeNames = [];
	let previousParents = "";

	try
	{
		data = JSON.parse(json.replace(/(:\s*\[?\s*-?\d*)\.0/g, "$1.1")); // hack that forces floats to stay as floats
		scope = data;
	}
	catch (e)
	{
		return {
			go: "",
			error: e.message
		};
	}

	typename = format(typename || "AutoGenerated");
	append(`type ${typename} `);

	parseScope(scope);

	if (flatten)
		go += accumulator

	// add final newline for POSIX 3.206
	if (!go.endsWith(`\n`))
		go += `\n`

	return {
		go: go
	};


	function parseScope(scope, depth = 0)
	{
		if (typeof scope === "object" && scope !== null)
		{
			if (Array.isArray(scope))
			{
				let sliceType;
				const scopeLength = scope.length;

				for (let i = 0; i < scopeLength; i++)
				{
					const thisType = goType(scope[i]);
					if (!sliceType)
						sliceType = thisType;
					else if (sliceType != thisType)
					{
						sliceType = mostSpecificPossibleGoType(thisType, sliceType);
						if (sliceType == "any")
							break;
					}
				}

				const slice = flatten && ["struct", "slice"].includes(sliceType)
					? `[]${parent}`
					: `[]`;

				if (flatten && depth >= 2)
					appender(slice);
				else
					append(slice)
				if (sliceType == "struct") {
					const allFields = {};

					// for each field counts how many times appears
					for (let i = 0; i < scopeLength; i++)
					{
						const keys = Object.keys(scope[i])
						for (let k in keys)
						{
							let keyname = keys[k];
							if (!(keyname in allFields)) {
								allFields[keyname] = {
									value: scope[i][keyname],
									count: 0
								}
							}
							else {
								const existingValue = allFields[keyname].value;
								const currentValue = scope[i][keyname];

								if (!areSameType(existingValue, currentValue)) {
									if(existingValue !== null) {
										allFields[keyname].value = null // force type "any" if types are not identical
										console.warn(`Warning: key "${keyname}" uses multiple types. Defaulting to type "any".`)
									}
									allFields[keyname].count++
									continue
								}

								// if variable was first detected as int (7) and a second time as float64 (3.14)
								// then we want to select float64, not int. Similar for int64 and float64.
								if(areSameType(currentValue, 1))
									allFields[keyname].value = findBestValueForNumberType(existingValue, currentValue);

								if (areObjects(existingValue, currentValue)) {
									const comparisonResult = compareObjectKeys(
										Object.keys(currentValue),
										Object.keys(existingValue)
									)
									if (!comparisonResult) {
										keyname = `${keyname}_${uuidv4()}`;
										allFields[keyname] = {
											value: currentValue,
											count: 0
										};
									}
								}
							}
							allFields[keyname].count++;
						}
					}

					// create a common struct with all fields found in the current array
					// omitempty dict indicates if a field is optional
					const keys = Object.keys(allFields), struct = {}, omitempty = {};
					for (let k in keys)
					{
						const keyname = keys[k], elem = allFields[keyname];

						struct[keyname] = elem.value;
						omitempty[keyname] = elem.count != scopeLength;
					}
					parseStruct(depth + 1, innerTabs, struct, omitempty, previousParents); // finally parse the struct !!
				}
				else if (sliceType == "slice") {
					parseScope(scope[0], depth)
				}
				else {
					if (flatten && depth >= 2) {
						appender(sliceType || "any");
					} else {
						append(sliceType || "any");
					}
				}
			}
			else
			{
				if (flatten) {
					if (depth >= 2){
						appender(parent)
					}
					else {
						append(parent)
					}
				}
				parseStruct(depth + 1, innerTabs, scope, false, previousParents);
			}
		}
		else {
			if (flatten && depth >= 2){
				appender(goType(scope));
			}
			else {
				append(goType(scope));
			}
		}
	}

	function parseStruct(depth, innerTabs, scope, omitempty, oldParents)
	{
		if (flatten) {
			stack.push(
				depth >= 2
				? "\n"
				: ""
			)
		}

		const seenTypeNames = [];

		if (flatten && depth >= 2)
		{
			const parentType = `type ${parent}`;
			const scopeKeys = formatScopeKeys(Object.keys(scope));

			// this can only handle two duplicate items
			// future improvement will handle the case where there could
			// three or more duplicate keys with different values
			if (parent in seen && compareObjectKeys(scopeKeys, seen[parent])) {
				stack.pop();
				return
			}
			seen[parent] = scopeKeys;

			appender(`${parentType} struct {\n`);
			++innerTabs;
			const keys = Object.keys(scope);
			previousParents = parent
			for (let i in keys)
			{
				const keyname = getOriginalName(keys[i]);
				indenter(innerTabs)
				let typename
				// structs will be defined on the top level of the go file, so they need to be globally unique
				if (typeof scope[keys[i]] === "object" && scope[keys[i]] !== null) {
					typename = uniqueTypeName(format(keyname), globallySeenTypeNames, previousParents)
					globallySeenTypeNames.push(typename)
				} else {
					typename = uniqueTypeName(format(keyname), seenTypeNames)
					seenTypeNames.push(typename)
				}

				appender(typename+" ");
				parent = typename
				parseScope(scope[keys[i]], depth);
				appender(' `json:"'+keyname);
				if (allOmitempty || (omitempty && omitempty[keys[i]] === true))
				{
					appender(',omitempty');
				}
				appender('"`\n');
			}
			indenter(--innerTabs);
			appender("}");
			previousParents = oldParents;
		}
		else
		{
			append("struct {\n");
			++tabs;
			const keys = Object.keys(scope);
			previousParents = parent
			for (let i in keys)
			{
				const keyname = getOriginalName(keys[i]);
				indent(tabs);
				let typename
				// structs will be defined on the top level of the go file, so they need to be globally unique
				if (typeof scope[keys[i]] === "object" && scope[keys[i]] !== null) {
					typename = uniqueTypeName(format(keyname), globallySeenTypeNames, previousParents)
					globallySeenTypeNames.push(typename)
				} else {
					typename = uniqueTypeName(format(keyname), seenTypeNames)
					seenTypeNames.push(typename)
				}

				append(typename+" ");
				parent = typename
				parseScope(scope[keys[i]], depth);
				append(' `json:"'+keyname);
				if (allOmitempty || (omitempty && omitempty[keys[i]] === true))
				{
					append(',omitempty');
				}
				if (example && scope[keys[i]] !== "" && typeof scope[keys[i]] !== "object")
				{
					append('" example:"'+scope[keys[i]])
				}
				append('"`\n');
			}
			indent(--tabs);
			append("}");
			previousParents = oldParents;
		}
		if (flatten)
			accumulator += stack.pop();
	}

	function indent(tabs)
	{
		for (let i = 0; i < tabs; i++)
			go += '\t';
	}

	function append(str)
	{
		go += str;
	}

	function indenter(tabs)
	{
		for (let i = 0; i < tabs; i++)
			stack[stack.length - 1] += '\t';
	}

	function appender(str)
	{
		stack[stack.length - 1] += str;
	}

	// Generate a unique name to avoid duplicate struct field names.
	// This function appends a number at the end of the field name.
	function uniqueTypeName(name, seen, prefix=null) {
		if (seen.indexOf(name) === -1) {
			return name;
		}

		// check if we can get a unique name by prefixing it
		if(prefix) {
			name = prefix+name
			if (seen.indexOf(name) === -1) {
				return name;
			}
		}

		let i = 0;
		while (true) {
			let newName = name + i.toString();
			if (seen.indexOf(newName) === -1) {
				return newName;
			}

			i++;
		}
	}

	// Sanitizes and formats a string to make an appropriate identifier in Go
	function format(str)
	{
		str = formatNumber(str);

		let sanitized = toProperCase(str).replace(/[^a-z0-9]/ig, "")
		if (!sanitized) {
			return "NAMING_FAILED";
		}

		// After sanitizing the remaining characters can start with a number.
		// Run the sanitized string again trough formatNumber to make sure the identifier is Num[0-9] or Zero_... instead of 1.
		return formatNumber(sanitized)
	}

	// Adds a prefix to a number to make an appropriate identifier in Go
	function formatNumber(str) {
		if (!str)
			return "";
		else if (str.match(/^\d+$/))
			str = "Num" + str;
		else if (str.charAt(0).match(/\d/))
		{
			const numbers = {'0': "Zero_", '1': "One_", '2': "Two_", '3': "Three_",
				'4': "Four_", '5': "Five_", '6': "Six_", '7': "Seven_",
				'8': "Eight_", '9': "Nine_"};
			str = numbers[str.charAt(0)] + str.substr(1);
		}

		return str;
	}

	// Determines the most appropriate Go type
	function goType(val)
	{
		if (val === null)
			return "any";

		switch (typeof val)
		{
			case "string":
				if (/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(\+\d\d:\d\d|Z)$/.test(val))
					return "time.Time";
				else
					return "string";
			case "number":
				if (val % 1 === 0)
				{
					if (val > -2147483648 && val < 2147483647)
						return "int";
					else
						return "int64";
				}
				else
					return "float64";
			case "boolean":
				return "bool";
			case "object":
				if (Array.isArray(val))
					return "slice";
				return "struct";
			default:
				return "any";
		}
	}

	// change the value to expand ints and floats to their larger equivalent
	function findBestValueForNumberType(existingValue, newValue) {
		if (!areSameType(newValue, 1)) {
			console.error(`Error: currentValue ${newValue} is not a number`)
			return null // falls back to goType "any"
		}

		const newGoType = goType(newValue)
		const existingGoType = goType(existingValue)

		if (newGoType === existingGoType)
			return existingValue

		// always upgrade float64
		if (newGoType === "float64")
			return newValue
		if (existingGoType === "float64")
			return existingValue

		// it's too complex to distinguish int types and float32, so we force-upgrade to float64
		// if anyone has a better suggestion, PRs are welcome!
		if (newGoType.includes("float") && existingGoType.includes("int"))
			return Number.MAX_VALUE
		if (newGoType.includes("int") && existingGoType.includes("float"))
			return Number.MAX_VALUE

		if (newGoType.includes("int") && existingGoType.includes("int")) {
			const existingValueAbs = Math.abs(existingValue);
			const newValueAbs = Math.abs(newValue);

			// if the sum is overflowing, it's safe to assume numbers are very large. So we force int64.
			if (!isFinite(existingValueAbs + newValueAbs))
				return Number.MAX_SAFE_INTEGER

			// it's too complex to distinguish int8, int16, int32 and int64, so we just use the sum as best-guess
			return existingValueAbs + newValueAbs;
		}

		// There should be other cases
		console.error(`Error: something went wrong with findBestValueForNumberType() using the values: '${newValue}' and '${existingValue}'`)
		console.error("       Please report the problem to https://github.com/mholt/json-to-go/issues")
		return null // falls back to goType "any"
	}

	// Given two types, returns the more specific of the two
	function mostSpecificPossibleGoType(typ1, typ2)
	{
		if (typ1.substr(0, 5) == "float"
				&& typ2.substr(0, 3) == "int")
			return typ1;
		else if (typ1.substr(0, 3) == "int"
				&& typ2.substr(0, 5) == "float")
			return typ2;
		else
			return "any";
	}

	// Proper cases a string according to Go conventions
	function toProperCase(str)
	{
		// ensure that the SCREAMING_SNAKE_CASE is converted to snake_case
		if (str.match(/^[_A-Z0-9]+$/)) {
			str = str.toLowerCase();
		}

		// https://github.com/golang/lint/blob/5614ed5bae6fb75893070bdc0996a68765fdd275/lint.go#L771-L810
		const commonInitialisms = [
			"ACL", "API", "ASCII", "CPU", "CSS", "DNS", "EOF", "GUID", "HTML", "HTTP",
			"HTTPS", "ID", "IP", "JSON", "LHS", "QPS", "RAM", "RHS", "RPC", "SLA",
			"SMTP", "SQL", "SSH", "TCP", "TLS", "TTL", "UDP", "UI", "UID", "UUID",
			"URI", "URL", "UTF8", "VM", "XML", "XMPP", "XSRF", "XSS"
		];

		return str.replace(/(^|[^a-zA-Z])([a-z]+)/g, function(unused, sep, frag)
		{
			if (commonInitialisms.indexOf(frag.toUpperCase()) >= 0)
				return sep + frag.toUpperCase();
			else
				return sep + frag[0].toUpperCase() + frag.substr(1).toLowerCase();
		}).replace(/([A-Z])([a-z]+)/g, function(unused, sep, frag)
		{
			if (commonInitialisms.indexOf(sep + frag.toUpperCase()) >= 0)
				return (sep + frag).toUpperCase();
			else
				return sep + frag;
		});
	}

	function uuidv4() {
		return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
		  var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
		  return v.toString(16);
		});
	}

	function getOriginalName(unique) {
		const reLiteralUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
		const uuidLength = 36;

		if (unique.length >= uuidLength) {
			const tail = unique.substr(-uuidLength);
			if (reLiteralUUID.test(tail)) {
				return unique.slice(0, -1 * (uuidLength + 1))
			}
		}
		return unique
	}

	function areObjects(objectA, objectB) {
		const object = "[object Object]";
		return Object.prototype.toString.call(objectA) === object
			&& Object.prototype.toString.call(objectB) === object;
	}

	function areSameType(objectA, objectB) {
		// prototype.toString required to compare Arrays and Objects
		const typeA =  Object.prototype.toString.call(objectA)
		const typeB = Object.prototype.toString.call(objectB)
		return typeA === typeB
	}

	function compareObjectKeys(itemAKeys, itemBKeys) {
		const lengthA = itemAKeys.length;
		const lengthB = itemBKeys.length;

		// nothing to compare, probably identical
		if (lengthA == 0 && lengthB == 0)
			return true;

		// duh
		if (lengthA != lengthB)
			return false;

		for (let item of itemAKeys) {
			if (!itemBKeys.includes(item))
				return false;
		}
		return true;
	}

	function formatScopeKeys(keys) {
		for (let i in keys) {
			keys[i] = format(keys[i]);
		}
		return keys
	}
}

if (typeof module != 'undefined') {
	if (!module.parent) {
		let filename = null

		function jsonToGoWithErrorHandling(json) {
			const output = jsonToGo(json)
			if (output.error) {
				console.error(output.error)
				process.exitCode = 1
			}
			process.stdout.write(output.go)
		}

		process.argv.forEach((val, index) => {
			if (index < 2)
				return

			if (!val.startsWith('-')) {
				filename = val
				return
			}

			const argument = val.replace(/-/g, '')
			if (argument === "big")
				console.warn(`Warning: The argument '${argument}' has been deprecated and has no effect anymore`)
			else {
				console.error(`Unexpected argument ${val} received`)
				process.exit(1)
			}
		})

		if (filename) {
			const fs = require('fs');
			const json = fs.readFileSync(filename, 'utf8');
			jsonToGoWithErrorHandling(json)
			return
		}

		if (!filename) {
			bufs = []
			process.stdin.on('data', function(buf) {
				bufs.push(buf)
			})
			process.stdin.on('end', function() {
				const json = Buffer.concat(bufs).toString('utf8')
				jsonToGoWithErrorHandling(json)
			})
			return
		}
	} else {
		module.exports = jsonToGo
	}
}
